<?php
/**
 * Copyright since 2007 PrestaShop SA and Contributors
 * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Open Software License (OSL 3.0)
 * that is bundled with this package in the file LICENSE.md.
 * It is also available through the world-wide-web at this URL:
 * https://opensource.org/licenses/OSL-3.0
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@prestashop.com so we can send you a copy immediately.
 *
 * DISCLAIMER
 *
 * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
 * versions in the future. If you wish to customize PrestaShop for your
 * needs please refer to https://devdocs.prestashop.com/ for more information.
 *
 * @author    PrestaShop SA and Contributors <contact@prestashop.com>
 * @copyright Since 2007 PrestaShop SA and Contributors
 * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
 */
use PrestaShop\PrestaShop\Adapter\ContainerFinder;
use PrestaShop\PrestaShop\Adapter\ServiceLocator;
use PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagSettings;
use PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagStateCheckerInterface;

class CarrierCore extends ObjectModel
{
    /**
     * getCarriers method filter.
     */
    public const PS_CARRIERS_ONLY = 1;
    public const CARRIERS_MODULE = 2;
    public const CARRIERS_MODULE_NEED_RANGE = 3;
    public const PS_CARRIERS_AND_CARRIER_MODULES_NEED_RANGE = 4;
    public const ALL_CARRIERS = 5;

    public const SHIPPING_METHOD_DEFAULT = 0;
    public const SHIPPING_METHOD_WEIGHT = 1;
    public const SHIPPING_METHOD_PRICE = 2;
    public const SHIPPING_METHOD_FREE = 3;

    public const SHIPPING_PRICE_EXCEPTION = 0;
    public const SHIPPING_WEIGHT_EXCEPTION = 1;
    public const SHIPPING_SIZE_EXCEPTION = 2;

    public const SORT_BY_PRICE = 0;
    public const SORT_BY_POSITION = 1;

    public const SORT_BY_ASC = 0;
    public const SORT_BY_DESC = 1;

    /**
     * @var int This is a persistent ID of the carrier, used to identify the carrier in the database.
     *          It remains the same during carrier updates, so it's a very good identifer to use
     *          in modules.
     */
    public $id_reference;

    /** @var string Name */
    public $name;

    /**
     * @var string Tracking URL for this carrier. Use @ as a variable placeholder that will be filled in
     *             with the tracking number of the order
     */
    public $url;

    /** @var string[]|string Delay needed to deliver customer */
    public $delay;

    /** @var bool Carrier status */
    public $active = true;

    /** @var bool True if carrier has been deleted (staying in database as deleted) */
    public $deleted = false;

    /** @var bool True if extra shipping handling cost should be applied to this Carrier */
    public $shipping_handling = true;

    /**
     * @var bool Behavior if the carrier is not within configured ranges.
     *           If set to true, carrier will be disabled.
     *           If false, the most expensive range will be used.
     */
    public $range_behavior;

    /**
     * @var bool True if the carrier is related to an external module. This needs to go hand-in-hand
     *           with the `external_module_name` property below, which contains the name of the module.
     *           Fill these two values programatically during configuration of the carrier by the module.
     */
    public $is_module;

    /** @var string Name of external module responsible for this Carrier, if filled in by a module, make sure
     *              that the `is_module` property is set to true.
     *              Fill these two values programatically during configuration of the carrier by the module.
     */
    public $external_module_name = null;

    /** @var bool Free carrier */
    public $is_free = false;

    /** @var int Shipping cost calculation method: by weight or by price or free */
    public $shipping_method = 0;

    /**
     * @var bool If true, an external module, if defined, will be asked to provide the shipping cost,
     *           otherwise the shipping cost will be calculated by the core.
     *           Fill this value programatically during configuration of the carrier by the module.
     *
     * @see Cart::getPackageShippingCostFromModule()
     */
    public $shipping_external = false;

    /**
     * @var bool True if module needs core range-based shipping cost to calculate final cost
     *
     * @see Cart::getPackageShippingCostFromModule()
     */
    public $need_range = false;

    /** @var int Position */
    public $position;

    /** @var int maximum package width managed by the transporter */
    public $max_width;

    /** @var int maximum package height managed by the transporter */
    public $max_height;

    /** @var int maximum package deep managed by the transporter */
    public $max_depth;

    /** @var float maximum package weight managed by the transporter */
    public $max_weight;

    /** @var int grade of the shipping delay (0 for longest, 9 for shortest) */
    public $grade;

    /**
     * @see ObjectModel::$definition
     */
    public static $definition = [
        'table' => 'carrier',
        'primary' => 'id_carrier',
        'multilang' => true,
        'multilang_shop' => true,
        'fields' => [
            /* Classic fields */
            'id_reference' => ['type' => self::TYPE_INT],
            'name' => ['type' => self::TYPE_STRING, 'validate' => 'isCarrierName', 'required' => true, 'size' => 64],
            'active' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool', 'required' => true],
            'is_free' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
            'url' => ['type' => self::TYPE_STRING, 'validate' => 'isAbsoluteUrl', 'size' => 255],
            'shipping_handling' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
            'shipping_external' => ['type' => self::TYPE_BOOL],
            'range_behavior' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
            'shipping_method' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
            'max_width' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
            'max_height' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
            'max_depth' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
            'max_weight' => ['type' => self::TYPE_FLOAT, 'validate' => 'isFloat'],
            'grade' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt', 'size' => 1],
            'external_module_name' => ['type' => self::TYPE_STRING, 'size' => 64],
            'is_module' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
            'need_range' => ['type' => self::TYPE_BOOL],
            'position' => ['type' => self::TYPE_INT],
            'deleted' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],

            /* Lang fields */
            'delay' => ['type' => self::TYPE_STRING, 'lang' => true, 'validate' => 'isGenericName', 'required' => true, 'size' => 512],
        ],
    ];

    protected static $price_by_weight = [];
    protected static $price_by_weight2 = [];
    protected static $price_by_price = [];
    protected static $price_by_price2 = [];

    protected static $cache_tax_rule = [];

    protected $webserviceParameters = [
        'fields' => [
            'deleted' => [],
            'is_module' => [],
            'id_tax_rules_group' => [
                'getter' => 'getIdTaxRulesGroup',
                'setter' => 'setTaxRulesGroup',
                'xlink_resource' => [
                    'resourceName' => 'tax_rule_groups',
                ],
            ],
        ],
    ];

    /**
     * CarrierCore constructor.
     *
     * @param int|null $id Carrier ID
     * @param int|null $id_lang Language ID
     */
    public function __construct($id = null, $id_lang = null)
    {
        parent::__construct($id, $id_lang);
        $this->image_dir = _PS_SHIP_IMG_DIR_;
        /*
         * keep retrocompatibility SHIPPING_METHOD_DEFAULT
         */
        if ($this->shipping_method == Carrier::SHIPPING_METHOD_DEFAULT) {
            $this->shipping_method = ((int) Configuration::get('PS_SHIPPING_METHOD') ? Carrier::SHIPPING_METHOD_WEIGHT : Carrier::SHIPPING_METHOD_PRICE);
        }
    }

    public static function resetStaticCache()
    {
        static::$price_by_weight = [];
        static::$price_by_weight2 = [];
        static::$price_by_price = [];
        static::$price_by_price2 = [];
        static::$cache_tax_rule = [];
        Cache::clean('Carrier::*');
    }

    /**
     * Adds current Carrier as a new Object to the database.
     *
     * @param bool $autoDate Automatically set `date_upd` and `date_add` columns
     * @param bool $nullValues Whether we want to use NULL values instead of empty quotes values
     *
     * @return bool Whether the Carrier has been successfully added
     *
     * @throws PrestaShopDatabaseException
     * @throws PrestaShopException
     */
    public function add($autoDate = true, $nullValues = false)
    {
        if ($this->position <= 0) {
            $this->position = Carrier::getHigherPosition() + 1;
        }
        if (!parent::add($autoDate, $nullValues) || !Validate::isLoadedObject($this)) {
            return false;
        }
        if (!$count = Db::getInstance()->getValue('SELECT count(`id_carrier`) FROM `' . _DB_PREFIX_ . $this->def['table'] . '` WHERE `deleted` = 0')) {
            return false;
        }
        if ($count == 1) {
            Configuration::updateValue('PS_CARRIER_DEFAULT', (int) $this->id);
        }

        // Register reference
        Db::getInstance()->execute(
            'UPDATE `' . _DB_PREFIX_ . $this->def['table'] . '` SET `id_reference` = ' .
            (int) $this->id . ' WHERE `id_carrier` = ' . (int) $this->id
        );

        foreach (Shop::getContextListShopID() as $shopId) {
            foreach (Module::getPaymentModules() as $module) {
                Db::getInstance()->execute(
                    '
                    INSERT INTO `' . _DB_PREFIX_ . 'module_' . bqSQL('carrier') . '`
                    (`id_module`, `id_shop`, `id_' . bqSQL('reference') . '`)
                    VALUES (' . (int) $module['id_module'] . ',' . (int) $shopId . ',' . (int) $this->id . ')'
                );
            }
        }

        return true;
    }

    /**
     * @see ObjectModel::delete()
     */
    public function delete()
    {
        if ($this->isUsed()) {
            return parent::softDelete() && Carrier::cleanPositions();
        } else {
            if (!parent::delete()) {
                return false;
            }

            Carrier::cleanPositions();

            return Db::getInstance()->delete('cart_rule_carrier', 'id_carrier = ' . (int) $this->id)
                    && Db::getInstance()->delete('module_carrier', 'id_reference = ' . (int) $this->id_reference)
                    && $this->deleteTaxRulesGroup(Shop::getShops(true, null, true));
        }
    }

    /**
     * Change carrier id in delivery prices when updating a carrier.
     *
     * @param int $id_old Old Carrier ID
     */
    public function setConfiguration($id_old)
    {
        Db::getInstance()->execute('UPDATE `' . _DB_PREFIX_ . 'delivery` SET `id_carrier` = ' . (int) $this->id . ' WHERE `id_carrier` = ' . (int) $id_old);
    }

    /**
     * Get delivery price for a given weight and zone
     *
     * @param float $total_weight Total order weight
     * @param int $id_zone Zone ID (for customer delivery address)
     *
     * @return float|bool Delivery price, false if not possible
     */
    public function getDeliveryPriceByWeight($total_weight, $id_zone)
    {
        $id_carrier = (int) $this->id;
        $cache_key = $id_carrier . '_' . $total_weight . '_' . $id_zone;
        if (!isset(self::$price_by_weight[$cache_key])) {
            $sql = 'SELECT d.`price`
                    FROM `' . _DB_PREFIX_ . 'delivery` d
                    LEFT JOIN `' . _DB_PREFIX_ . 'range_weight` w ON (d.`id_range_weight` = w.`id_range_weight`)
                    WHERE d.`id_zone` = ' . (int) $id_zone . '
                        AND ' . (float) $total_weight . ' >= w.`delimiter1`
                        AND ' . (float) $total_weight . ' < w.`delimiter2`
                        AND d.`id_carrier` = ' . $id_carrier . '
                        ' . Carrier::sqlDeliveryRangeShop('range_weight') . '
                    ORDER BY w.`delimiter1` ASC';
            $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getRow($sql);
            if (!isset($result['price'])) {
                self::$price_by_weight[$cache_key] = $this->getMaxDeliveryPriceByWeight($id_zone);
            } else {
                self::$price_by_weight[$cache_key] = $result['price'];
            }
        }

        $price_by_weight = Hook::exec('actionDeliveryPriceByWeight', ['id_carrier' => $id_carrier, 'total_weight' => $total_weight, 'id_zone' => $id_zone]);
        if (is_numeric($price_by_weight)) {
            self::$price_by_weight[$cache_key] = $price_by_weight;
        }

        return self::$price_by_weight[$cache_key];
    }

    /**
     * Check if the carrier is available for a given weight and zone
     *
     * @param int $id_carrier Carrier ID
     * @param float $total_weight Total weight
     * @param int $id_zone Zone ID
     *
     * @return bool true if carrier is available
     */
    public static function checkDeliveryPriceByWeight($id_carrier, $total_weight, $id_zone)
    {
        $id_carrier = (int) $id_carrier;
        $cache_key = $id_carrier . '_' . $total_weight . '_' . $id_zone;
        if (!isset(self::$price_by_weight2[$cache_key])) {
            $sql = 'SELECT d.`price`
                    FROM `' . _DB_PREFIX_ . 'delivery` d
                    LEFT JOIN `' . _DB_PREFIX_ . 'range_weight` w ON d.`id_range_weight` = w.`id_range_weight`
                    WHERE d.`id_zone` = ' . (int) $id_zone . '
                        AND ' . (float) $total_weight . ' >= w.`delimiter1`
                        AND ' . (float) $total_weight . ' < w.`delimiter2`
                        AND d.`id_carrier` = ' . $id_carrier . '
                        ' . Carrier::sqlDeliveryRangeShop('range_weight') . '
                    ORDER BY w.`delimiter1` ASC';
            $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getRow($sql);
            self::$price_by_weight2[$cache_key] = (isset($result['price']));
        }

        $price_by_weight = Hook::exec('actionDeliveryPriceByWeight', ['id_carrier' => $id_carrier, 'total_weight' => $total_weight, 'id_zone' => $id_zone]);
        if (is_numeric($price_by_weight)) {
            self::$price_by_weight2[$cache_key] = true;
        }

        return self::$price_by_weight2[$cache_key];
    }

    /**
     * Get maximum delivery price when range weight is used.
     *
     * @param int $id_zone Zone ID
     *
     * @return false|string|null Maximum delivery price
     */
    public function getMaxDeliveryPriceByWeight($id_zone)
    {
        $cache_id = 'Carrier::getMaxDeliveryPriceByWeight_' . (int) $this->id . '-' . (int) $id_zone;
        if (!Cache::isStored($cache_id)) {
            $sql = 'SELECT d.`price`
                    FROM `' . _DB_PREFIX_ . 'delivery` d
                    INNER JOIN `' . _DB_PREFIX_ . 'range_weight` w ON d.`id_range_weight` = w.`id_range_weight`
                    WHERE d.`id_zone` = ' . (int) $id_zone . '
                        AND d.`id_carrier` = ' . (int) $this->id . '
                        ' . Carrier::sqlDeliveryRangeShop('range_weight') . '
                    ORDER BY w.`delimiter2` DESC';
            $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue($sql);
            Cache::store($cache_id, $result);

            return $result;
        }

        return Cache::retrieve($cache_id);
    }

    /**
     * Get delivery price for a given order by total order price MINUS shipping costs.
     *
     * @param float $order_total Order total to pay
     * @param int $id_zone Zone id (for customer delivery address)
     * @param int|null $id_currency Currency ID
     *
     * @return float Maximum delivery price
     */
    public function getDeliveryPriceByPrice($order_total, $id_zone, $id_currency = null)
    {
        $id_carrier = (int) $this->id;
        $cache_key = $this->id . '_' . $order_total . '_' . $id_zone . '_' . $id_currency;
        if (!isset(self::$price_by_price[$cache_key])) {
            if (!empty($id_currency)) {
                $order_total = Tools::convertPrice($order_total, $id_currency, false);
            }

            $sql = 'SELECT d.`price`
                    FROM `' . _DB_PREFIX_ . 'delivery` d
                    LEFT JOIN `' . _DB_PREFIX_ . 'range_price` r ON d.`id_range_price` = r.`id_range_price`
                    WHERE d.`id_zone` = ' . (int) $id_zone . '
                        AND ' . (float) $order_total . ' >= r.`delimiter1`
                        AND ' . (float) $order_total . ' < r.`delimiter2`
                        AND d.`id_carrier` = ' . $id_carrier . '
                        ' . Carrier::sqlDeliveryRangeShop('range_price') . '
                    ORDER BY r.`delimiter1` ASC';
            $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getRow($sql);
            if (!isset($result['price'])) {
                self::$price_by_price[$cache_key] = $this->getMaxDeliveryPriceByPrice($id_zone);
            } else {
                self::$price_by_price[$cache_key] = $result['price'];
            }
        }

        $price_by_price = Hook::exec('actionDeliveryPriceByPrice', ['id_carrier' => $id_carrier, 'order_total' => $order_total, 'id_zone' => $id_zone]);
        if (is_numeric($price_by_price)) {
            self::$price_by_price[$cache_key] = $price_by_price;
        }

        return self::$price_by_price[$cache_key];
    }

    /**
     * Check if the carrier is available for a given order total, currency and zone
     *
     * @param int $id_carrier Carrier ID
     * @param float $order_total Order total to pay
     * @param int $id_zone Zone id (for customer delivery address)
     * @param int|null $id_currency Currency ID
     *
     * @return bool true if carrier is available
     */
    public static function checkDeliveryPriceByPrice($id_carrier, $order_total, $id_zone, $id_currency = null)
    {
        $id_carrier = (int) $id_carrier;
        $cache_key = $id_carrier . '_' . $order_total . '_' . $id_zone . '_' . $id_currency;
        if (!isset(self::$price_by_price2[$cache_key])) {
            if (!empty($id_currency)) {
                $order_total = Tools::convertPrice($order_total, $id_currency, false);
            }

            $sql = 'SELECT d.`price`
                    FROM `' . _DB_PREFIX_ . 'delivery` d
                    LEFT JOIN `' . _DB_PREFIX_ . 'range_price` r ON d.`id_range_price` = r.`id_range_price`
                    WHERE d.`id_zone` = ' . (int) $id_zone . '
                        AND ' . (float) $order_total . ' >= r.`delimiter1`
                        AND ' . (float) $order_total . ' < r.`delimiter2`
                        AND d.`id_carrier` = ' . $id_carrier . '
                        ' . Carrier::sqlDeliveryRangeShop('range_price') . '
                    ORDER BY r.`delimiter1` ASC';
            $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getRow($sql);
            self::$price_by_price2[$cache_key] = (isset($result['price']));
        }

        $price_by_price = Hook::exec('actionDeliveryPriceByPrice', ['id_carrier' => $id_carrier, 'order_total' => $order_total, 'id_zone' => $id_zone]);
        if (is_numeric($price_by_price)) {
            self::$price_by_price2[$cache_key] = true;
        }

        return self::$price_by_price2[$cache_key];
    }

    /**
     * Get maximum delivery price by order total MINUS shipping costs.
     *
     * @param int $id_zone Zone ID
     *
     * @return false|string Maximum delivery price
     */
    public function getMaxDeliveryPriceByPrice($id_zone)
    {
        $cache_id = 'Carrier::getMaxDeliveryPriceByPrice_' . (int) $this->id . '-' . (int) $id_zone;
        if (!Cache::isStored($cache_id)) {
            $sql = 'SELECT d.`price`
                    FROM `' . _DB_PREFIX_ . 'delivery` d
                    INNER JOIN `' . _DB_PREFIX_ . 'range_price` r ON d.`id_range_price` = r.`id_range_price`
                    WHERE d.`id_zone` = ' . (int) $id_zone . '
                        AND d.`id_carrier` = ' . (int) $this->id . '
                        ' . Carrier::sqlDeliveryRangeShop('range_price') . '
                    ORDER BY r.`delimiter2` DESC';
            $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue($sql);
            Cache::store($cache_id, $result);
        }

        return Cache::retrieve($cache_id);
    }

    /**
     * Get delivery prices for a given shipping method (price/weight).
     *
     * @param string $range_table Table name (price or weight)
     * @param int $id_carrier Carrier ID
     *
     * @return array Delivery prices
     */
    public static function getDeliveryPriceByRanges($range_table, $id_carrier)
    {
        $sql = 'SELECT d.`id_' . bqSQL($range_table) . '`, d.id_carrier, d.id_zone, d.price
                FROM ' . _DB_PREFIX_ . 'delivery d
                LEFT JOIN `' . _DB_PREFIX_ . bqSQL($range_table) . '` r ON r.`id_' . bqSQL($range_table) . '` = d.`id_' . bqSQL($range_table) . '`
                WHERE d.id_carrier = ' . (int) $id_carrier . '
                    AND d.`id_' . bqSQL($range_table) . '` IS NOT NULL
                    AND d.`id_' . bqSQL($range_table) . '` != 0
                    ' . Carrier::sqlDeliveryRangeShop($range_table) . '
                ORDER BY r.delimiter1';

        return Db::getInstance()->executeS($sql);
    }

    /**
     * Get all carriers in a given language.
     *
     * @param int $id_lang Language id
     * @param int $modules_filters Possible values:
     *                             - PS_CARRIERS_ONLY
     *                             - CARRIERS_MODULE
     *                             - CARRIERS_MODULE_NEED_RANGE
     *                             - PS_CARRIERS_AND_CARRIER_MODULES_NEED_RANGE
     *                             - ALL_CARRIERS
     * @param bool $active Returns only active carriers when true
     * @param array|string|null $ids_group
     *
     * @return array Carriers
     */
    public static function getCarriers($id_lang, $active = false, $delete = false, $id_zone = false, $ids_group = null, $modules_filters = self::PS_CARRIERS_ONLY)
    {
        // Filter by groups and no groups => return empty array
        if ($ids_group && !is_array($ids_group)) {
            return [];
        }

        $sql = 'SELECT c.*, cl.delay
                FROM `' . _DB_PREFIX_ . 'carrier` c
                LEFT JOIN `' . _DB_PREFIX_ . 'carrier_lang` cl ON (c.`id_carrier` = cl.`id_carrier` AND cl.`id_lang` = ' . (int) $id_lang . Shop::addSqlRestrictionOnLang('cl') . ')
                LEFT JOIN `' . _DB_PREFIX_ . 'carrier_zone` cz ON (cz.`id_carrier` = c.`id_carrier`)' .
                ($id_zone ? 'LEFT JOIN `' . _DB_PREFIX_ . 'zone` z ON (z.`id_zone` = ' . (int) $id_zone . ')' : '') . '
                ' . Shop::addSqlAssociation('carrier', 'c') . '
                WHERE c.`deleted` = ' . ($delete ? '1' : '0');
        if ($active) {
            $sql .= ' AND c.`active` = 1 ';
        }
        if ($id_zone) {
            $sql .= ' AND cz.`id_zone` = ' . (int) $id_zone . ' AND z.`active` = 1 ';
        }
        if ($ids_group) {
            $sql .= ' AND EXISTS ('
                . 'SELECT 1 FROM ' . _DB_PREFIX_ . 'carrier_group '
                . 'WHERE ' . _DB_PREFIX_ . 'carrier_group.id_carrier = c.id_carrier '
                . 'AND id_group IN (' . implode(',', array_map('intval', $ids_group)) . ')'
                . ') ';
        }

        switch ($modules_filters) {
            case self::PS_CARRIERS_ONLY:
                $sql .= ' AND c.is_module = 0 ';

                break;
            case self::CARRIERS_MODULE:
                $sql .= ' AND c.is_module = 1 ';

                break;
            case self::CARRIERS_MODULE_NEED_RANGE:
                $sql .= ' AND c.is_module = 1 AND c.need_range = 1 ';

                break;
            case self::PS_CARRIERS_AND_CARRIER_MODULES_NEED_RANGE:
                $sql .= ' AND (c.is_module = 0 OR c.need_range = 1) ';

                break;
        }
        $sql .= ' GROUP BY c.`id_carrier` ORDER BY c.`position` ASC';

        $cache_id = 'Carrier::getCarriers_' . md5($sql);
        if (!Cache::isStored($cache_id)) {
            $carriers = Db::getInstance()->executeS($sql);
            Cache::store($cache_id, $carriers);
        } else {
            $carriers = Cache::retrieve($cache_id);
        }

        return $carriers;
    }

    /**
     * Get most used Tax rules group.
     *
     * @return false|string|null Most used Tax rules group ID
     */
    public static function getIdTaxRulesGroupMostUsed()
    {
        return Db::getInstance()->getValue(
            'SELECT id_tax_rules_group
            FROM (
                SELECT COUNT(*) n, c.id_tax_rules_group
                FROM ' . _DB_PREFIX_ . 'carrier_tax_rules_group_shop c
                JOIN ' . _DB_PREFIX_ . 'tax_rules_group trg ON (c.id_tax_rules_group = trg.id_tax_rules_group)
                WHERE trg.active = 1 AND trg.deleted = 0
                GROUP BY c.id_tax_rules_group
                ORDER BY n DESC
                LIMIT 1
            ) most_used'
        );
    }

    /**
     * Get the countries to which can be delivered.
     *
     * @param int $id_lang Language ID
     * @param bool $active_countries Only return active countries when true
     * @param bool $active_carriers Only return active carriers when true
     * @param int|null $contain_states Only return countries with states
     *
     * @return array Countries to which can be delivered
     */
    public static function getDeliveredCountries(int $id_lang, bool $active_countries = false, bool $active_carriers = false, $contain_states = null)
    {
        $states = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS('
            SELECT s.*
            FROM `' . _DB_PREFIX_ . 'state` s
            ORDER BY s.`name` ASC');

        $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS('
            SELECT cl.*,c.*, cl.`name` AS country, zz.`name` AS zone
            FROM `' . _DB_PREFIX_ . 'country` c' .
            Shop::addSqlAssociation('country', 'c') . '
            LEFT JOIN `' . _DB_PREFIX_ . 'country_lang` cl ON (c.`id_country` = cl.`id_country` AND cl.`id_lang` = ' . (int) $id_lang . ')
            INNER JOIN (`' . _DB_PREFIX_ . 'carrier_zone` cz INNER JOIN `' . _DB_PREFIX_ . 'carrier` cr ON ( cr.id_carrier = cz.id_carrier AND cr.deleted = 0 ' .
            ($active_carriers ? 'AND cr.active = 1) ' : ') ') . '
            LEFT JOIN `' . _DB_PREFIX_ . 'zone` zz ON cz.id_zone = zz.id_zone) ON zz.`id_zone` = c.`id_zone`
            WHERE 1
            ' . ($active_countries ? 'AND c.active = 1' : '') . '
            ' . (null !== $contain_states ? 'AND c.`contains_states` = ' . (int) $contain_states : '') . '
            ORDER BY cl.name ASC');

        $countries = [];
        foreach ($result as $country) {
            $countries[$country['id_country']] = $country;
        }
        foreach ($states as $state) {
            if (isset($countries[$state['id_country']])) { /* Does not keep the state if its country has been disabled and not selected */
                if ($state['active'] == 1) {
                    $countries[$state['id_country']]['states'][] = $state;
                }
            }
        }

        return $countries;
    }

    /**
     * Return the default carrier to use.
     *
     * @param array $carriers Carriers
     * @param int $default_carrier The last selected Carrier ID
     *
     * @return number the id of the default carrier
     */
    public static function getDefaultCarrierSelection($carriers, $default_carrier = 0)
    {
        if (empty($carriers)) {
            return 0;
        }

        if ((int) $default_carrier != 0) {
            foreach ($carriers as $carrier) {
                if ($carrier['id_carrier'] == (int) $default_carrier) {
                    return (int) $carrier['id_carrier'];
                }
            }
        }
        foreach ($carriers as $carrier) {
            if ($carrier['id_carrier'] == (int) Configuration::get('PS_CARRIER_DEFAULT')) {
                return (int) $carrier['id_carrier'];
            }
        }

        return (int) $carriers[0]['id_carrier'];
    }

    /**
     * Get available Carriers for given order (cart)
     *
     * @param int $id_zone Zone ID
     * @param array|null $groups Group of the Customer
     * @param CartCore|null $cart Optional Cart object
     * @param array $error Contains an error message if an error occurs
     *
     * @return array Carriers for the order
     */
    public static function getCarriersForOrder($id_zone, $groups = null, $cart = null, &$error = [])
    {
        // First, initialize the context, language, currency
        $context = Context::getContext();
        $id_lang = $context->language->id;
        if (null === $cart) {
            $cart = $context->cart;
        }
        if (isset($context->currency)) {
            $id_currency = $context->currency->id;
        }

        // Use provided groups or a default group if none provided
        if (!is_array($groups) || empty($groups)) {
            $groups = [Configuration::get('PS_UNIDENTIFIED_GROUP')];
        }

        // And get all carriers available in the system
        $result = Carrier::getCarriers($id_lang, true, false, (int) $id_zone, $groups, self::PS_CARRIERS_AND_CARRIER_MODULES_NEED_RANGE);
        $results_array = [];

        foreach ($result as $k => $row) {
            $carrier = new Carrier((int) $row['id_carrier']);
            $shipping_method = $carrier->getShippingMethod();
            if ($shipping_method != Carrier::SHIPPING_METHOD_FREE) {
                /*
                 * First, we check loosely if the carrier is available for the zone with at least one range.
                 * No weight, no price, just check if the carrier has any ranges for the zone.
                 * If not, we remove it from the list immediately.
                 * If yes, we must still check the behavior of the carrier for out-of-range prices below.
                 */
                if ($shipping_method == Carrier::SHIPPING_METHOD_WEIGHT && $carrier->getMaxDeliveryPriceByWeight($id_zone) === false) {
                    $error[$carrier->id] = Carrier::SHIPPING_WEIGHT_EXCEPTION;
                    unset($result[$k]);

                    continue;
                }
                if ($shipping_method == Carrier::SHIPPING_METHOD_PRICE && $carrier->getMaxDeliveryPriceByPrice($id_zone) === false) {
                    $error[$carrier->id] = Carrier::SHIPPING_PRICE_EXCEPTION;
                    unset($result[$k]);

                    continue;
                }

                /*
                 * Second, if out-of-range behavior carrier is set to "Deactivate carrier", we have to specifically check
                 * for current weight/price of the cart and remove the carrier if it is not available for the current cart.
                 */
                if ($row['range_behavior']) {
                    // Get id zone
                    if (!$id_zone) {
                        $id_zone = (int) Country::getIdZone((int) Configuration::get('PS_COUNTRY_DEFAULT'));
                    }

                    // Get only carriers that have a range compatible with cart
                    if ($shipping_method == Carrier::SHIPPING_METHOD_WEIGHT
                        && (Carrier::checkDeliveryPriceByWeight($row['id_carrier'], $cart->getTotalWeight(), $id_zone) === false)) {
                        $error[$carrier->id] = Carrier::SHIPPING_WEIGHT_EXCEPTION;
                        unset($result[$k]);

                        continue;
                    }

                    if ($shipping_method == Carrier::SHIPPING_METHOD_PRICE
                        && (Carrier::checkDeliveryPriceByPrice($row['id_carrier'], $cart->getOrderTotal(true, Cart::BOTH_WITHOUT_SHIPPING), $id_zone, $id_currency ?? null) === false)) {
                        $error[$carrier->id] = Carrier::SHIPPING_PRICE_EXCEPTION;
                        unset($result[$k]);

                        continue;
                    }
                }
            }

            // Calculate the price of the carrier
            $row['price'] = (($shipping_method == Carrier::SHIPPING_METHOD_FREE) ? 0 : $cart->getPackageShippingCost((int) $row['id_carrier'], true, null, null, $id_zone));
            $row['price_tax_exc'] = (($shipping_method == Carrier::SHIPPING_METHOD_FREE) ? 0 : $cart->getPackageShippingCost((int) $row['id_carrier'], false, null, null, $id_zone));

            // If price is false, then the carrier is unavailable (carrier module)
            if ($row['price'] === false) {
                unset($result[$k]);

                continue;
            }

            // Locate an image (original resolution, we should probably move to use a presenter and thumbnails here)
            $row['img'] = file_exists(_PS_SHIP_IMG_DIR_ . (int) $row['id_carrier'] . '.jpg') ? _THEME_SHIP_DIR_ . (int) $row['id_carrier'] . '.jpg' : '';

            $results_array[] = $row;
        }

        // Sort carriers by price if needed
        $prices = [];
        if (Configuration::get('PS_CARRIER_DEFAULT_SORT') == Carrier::SORT_BY_PRICE) {
            foreach ($results_array as $r) {
                $prices[] = $r['price'];
            }
            if (Configuration::get('PS_CARRIER_DEFAULT_ORDER') == Carrier::SORT_BY_ASC) {
                array_multisort($prices, SORT_ASC, SORT_NUMERIC, $results_array);
            } else {
                array_multisort($prices, SORT_DESC, SORT_NUMERIC, $results_array);
            }
        }

        return $results_array;
    }

    public static function checkCarrierZone($id_carrier, $id_zone)
    {
        $cache_id = 'Carrier::checkCarrierZone_' . (int) $id_carrier . '-' . (int) $id_zone;
        if (!Cache::isStored($cache_id)) {
            $sql = 'SELECT c.`id_carrier`
                    FROM `' . _DB_PREFIX_ . 'carrier` c
                    LEFT JOIN `' . _DB_PREFIX_ . 'carrier_zone` cz ON (cz.`id_carrier` = c.`id_carrier`)
                    LEFT JOIN `' . _DB_PREFIX_ . 'zone` z ON (z.`id_zone` = ' . (int) $id_zone . ')
                    WHERE c.`id_carrier` = ' . (int) $id_carrier . '
                    AND c.`deleted` = 0
                    AND c.`active` = 1
                    AND cz.`id_zone` = ' . (int) $id_zone . '
                    AND z.`active` = 1';
            $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue($sql);
            Cache::store($cache_id, $result);
        }

        return Cache::retrieve($cache_id);
    }

    /**
     * Get all zones.
     *
     * @return array Zones
     */
    public function getZones()
    {
        return Db::getInstance()->executeS('
            SELECT *
            FROM `' . _DB_PREFIX_ . 'carrier_zone` cz
            LEFT JOIN `' . _DB_PREFIX_ . 'zone` z ON cz.`id_zone` = z.`id_zone`
            WHERE cz.`id_carrier` = ' . (int) $this->id);
    }

    /**
     * Get a specific zones.
     *
     * @return array Zone
     */
    public function getZone($id_zone)
    {
        return Db::getInstance()->executeS('
            SELECT *
            FROM `' . _DB_PREFIX_ . 'carrier_zone`
            WHERE `id_carrier` = ' . (int) $this->id . '
            AND `id_zone` = ' . (int) $id_zone);
    }

    /**
     * Add zone.
     */
    public function addZone($id_zone)
    {
        if (Db::getInstance()->execute('
            INSERT INTO `' . _DB_PREFIX_ . 'carrier_zone` (`id_carrier` , `id_zone`)
            VALUES (' . (int) $this->id . ', ' . (int) $id_zone . ')
        ')) {
            // Get all ranges for this carrier
            $ranges_price = RangePrice::getRanges($this->id);
            $ranges_weight = RangeWeight::getRanges($this->id);
            // Create row in ps_delivery table
            if (count($ranges_price) || count($ranges_weight)) {
                $sql = 'INSERT INTO `' . _DB_PREFIX_ . 'delivery` (`id_carrier`, `id_range_price`, `id_range_weight`, `id_zone`, `price`) VALUES ';
                if (count($ranges_price)) {
                    foreach ($ranges_price as $range) {
                        $sql .= '(' . (int) $this->id . ', ' . (int) $range['id_range_price'] . ', 0, ' . (int) $id_zone . ', 0),';
                    }
                }

                if (count($ranges_weight)) {
                    foreach ($ranges_weight as $range) {
                        $sql .= '(' . (int) $this->id . ', 0, ' . (int) $range['id_range_weight'] . ', ' . (int) $id_zone . ', 0),';
                    }
                }
                $sql = rtrim($sql, ',');

                return Db::getInstance()->execute($sql);
            }

            return true;
        }

        return false;
    }

    /**
     * Delete zone.
     */
    public function deleteZone($id_zone)
    {
        if (Db::getInstance()->execute('
            DELETE FROM `' . _DB_PREFIX_ . 'carrier_zone`
            WHERE `id_carrier` = ' . (int) $this->id . '
            AND `id_zone` = ' . (int) $id_zone . ' LIMIT 1
        ')) {
            return Db::getInstance()->execute('
                DELETE FROM `' . _DB_PREFIX_ . 'delivery`
                WHERE `id_carrier` = ' . (int) $this->id . '
                AND `id_zone` = ' . (int) $id_zone);
        }

        return false;
    }

    /**
     * Gets a specific group.
     *
     * @return array Group
     */
    public function getGroups()
    {
        return Db::getInstance()->executeS('
            SELECT id_group
            FROM ' . _DB_PREFIX_ . 'carrier_group
            WHERE id_carrier=' . (int) $this->id);
    }

    public function getAssociatedGroupIds(): array
    {
        return array_map(fn ($group) => (int) $group['id_group'], $this->getGroups());
    }

    /**
     * Clean delivery prices (weight/price).
     *
     * @param string $range_table Table name to clean (weight or price according to shipping method)
     *
     * @return bool Deletion result
     */
    public function deleteDeliveryPrice($range_table)
    {
        $where = '`id_carrier` = ' . (int) $this->id . ' AND (`id_' . bqSQL($range_table) . '` IS NOT NULL OR `id_' . bqSQL($range_table) . '` = 0) ';

        if (Shop::getContext() == Shop::CONTEXT_ALL) {
            $where .= 'AND id_shop IS NULL AND id_shop_group IS NULL';
        } elseif (Shop::getContext() == Shop::CONTEXT_GROUP) {
            $where .= 'AND id_shop IS NULL AND id_shop_group = ' . (int) Shop::getContextShopGroupID();
        } else {
            $where .= 'AND id_shop = ' . (int) Shop::getContextShopID();
        }

        return Db::getInstance()->delete('delivery', $where);
    }

    /**
     * Add new delivery prices.
     *
     * @param array $price_list Prices list in multiple arrays (changed to array since 1.5.0)
     * @param bool $delete
     *
     * @return bool Insertion result
     */
    public function addDeliveryPrice($price_list, $delete = false)
    {
        if (!$price_list) {
            return false;
        }

        $keys = array_keys($price_list[0]);
        if (!in_array('id_shop', $keys)) {
            $keys[] = 'id_shop';
        }
        if (!in_array('id_shop_group', $keys)) {
            $keys[] = 'id_shop_group';
        }

        $sql = 'INSERT INTO `' . _DB_PREFIX_ . 'delivery` (' . implode(', ', $keys) . ') VALUES ';
        foreach ($price_list as $values) {
            if (!isset($values['id_shop'])) {
                $values['id_shop'] = (Shop::getContext() == Shop::CONTEXT_SHOP) ? Shop::getContextShopID() : null;
            }
            if (!isset($values['id_shop_group'])) {
                $values['id_shop_group'] = (Shop::getContext() != Shop::CONTEXT_ALL) ? Shop::getContextShopGroupID() : null;
            }

            if ($delete) {
                Db::getInstance()->execute(
                    'DELETE FROM `' . _DB_PREFIX_ . 'delivery`
                    WHERE ' . (null === $values['id_shop'] ? 'ISNULL(`id_shop`) ' : 'id_shop = ' . (int) $values['id_shop']) . '
                    AND ' . (null === $values['id_shop_group'] ? 'ISNULL(`id_shop`) ' : 'id_shop_group=' . (int) $values['id_shop_group']) . '
                    AND id_carrier=' . (int) $values['id_carrier'] .
                    ($values['id_range_price'] !== null ? ' AND id_range_price=' . (int) $values['id_range_price'] : ' AND (ISNULL(`id_range_price`) OR `id_range_price` = 0)') .
                    ($values['id_range_weight'] !== null ? ' AND id_range_weight=' . (int) $values['id_range_weight'] : ' AND (ISNULL(`id_range_weight`) OR `id_range_weight` = 0)') . '
                    AND id_zone=' . (int) $values['id_zone']
                );
            }

            $sql .= '(';
            foreach ($values as $v) {
                if (null === $v) {
                    $sql .= 'NULL';
                } elseif (is_int($v) || is_float($v)) {
                    $sql .= $v;
                } else {
                    $sql .= '\'' . Db::getInstance()->escape($v, false, true) . '\'';
                }
                $sql .= ', ';
            }
            $sql = rtrim($sql, ', ') . '), ';
        }
        $sql = rtrim($sql, ', ');

        return Db::getInstance()->execute($sql);
    }

    /**
     * Copy old carrier informations when update carrier.
     *
     * @param int $old_id Old id carrier (copy from that id)
     */
    public function copyCarrierData($old_id)
    {
        if (!Validate::isUnsignedId($old_id)) {
            throw new PrestaShopException('Incorrect identifier for carrier');
        }

        if (!$this->id) {
            return false;
        }

        $old_logo = _PS_SHIP_IMG_DIR_ . '/' . (int) $old_id . '.jpg';
        if (file_exists($old_logo)) {
            copy($old_logo, _PS_SHIP_IMG_DIR_ . '/' . (int) $this->id . '.jpg');
        }

        $old_tmp_logo = _PS_TMP_IMG_DIR_ . '/carrier_mini_' . (int) $old_id . '.jpg';
        if (file_exists($old_tmp_logo)) {
            if (!isset($_FILES['logo'])) {
                copy($old_tmp_logo, _PS_TMP_IMG_DIR_ . '/carrier_mini_' . $this->id . '.jpg');
            }
            unlink($old_tmp_logo);
        }

        // Copy existing ranges price
        foreach (['range_price', 'range_weight'] as $range) {
            $res = Db::getInstance()->executeS('
                SELECT `id_' . $range . '` as id_range, `delimiter1`, `delimiter2`
                FROM `' . _DB_PREFIX_ . $range . '`
                WHERE `id_carrier` = ' . (int) $old_id);
            if (count($res)) {
                foreach ($res as $val) {
                    Db::getInstance()->execute('
                        INSERT INTO `' . _DB_PREFIX_ . $range . '` (`id_carrier`, `delimiter1`, `delimiter2`)
                        VALUES (' . (int) $this->id . ',' . (float) $val['delimiter1'] . ',' . (float) $val['delimiter2'] . ')');
                    $range_id = (int) Db::getInstance()->Insert_ID();

                    $range_price_id = ($range == 'range_price') ? $range_id : 'NULL';
                    $range_weight_id = ($range == 'range_weight') ? $range_id : 'NULL';

                    Db::getInstance()->execute('
                        INSERT INTO `' . _DB_PREFIX_ . 'delivery` (`id_carrier`, `id_shop`, `id_shop_group`, `id_range_price`, `id_range_weight`, `id_zone`, `price`) (
                            SELECT ' . (int) $this->id . ', `id_shop`, `id_shop_group`, ' . (int) $range_price_id . ', ' . (int) $range_weight_id . ', `id_zone`, `price`
                            FROM `' . _DB_PREFIX_ . 'delivery`
                            WHERE `id_carrier` = ' . (int) $old_id . '
                            AND `id_' . $range . '` = ' . (int) $val['id_range'] . '
                        )
                    ');
                }
            }
        }

        // Copy existing zones
        $res = Db::getInstance()->executeS('
            SELECT *
            FROM `' . _DB_PREFIX_ . 'carrier_zone`
            WHERE id_carrier = ' . (int) $old_id);
        foreach ($res as $val) {
            Db::getInstance()->execute('
                INSERT INTO `' . _DB_PREFIX_ . 'carrier_zone` (`id_carrier`, `id_zone`)
                VALUES (' . (int) $this->id . ',' . (int) $val['id_zone'] . ')
            ');
        }

        // Copy default carrier
        if (Configuration::get('PS_CARRIER_DEFAULT') == $old_id) {
            Configuration::updateValue('PS_CARRIER_DEFAULT', (int) $this->id);
        }

        // Copy reference
        $id_reference = Db::getInstance()->getValue('
            SELECT `id_reference`
            FROM `' . _DB_PREFIX_ . $this->def['table'] . '`
            WHERE id_carrier = ' . (int) $old_id);
        Db::getInstance()->execute('
            UPDATE `' . _DB_PREFIX_ . $this->def['table'] . '`
            SET `id_reference` = ' . (int) $id_reference . '
            WHERE `id_carrier` = ' . (int) $this->id);

        $this->id_reference = (int) $id_reference;

        // Copy tax rules group
        Db::getInstance()->execute('
            INSERT INTO `' . _DB_PREFIX_ . 'carrier_tax_rules_group_shop` (`id_carrier`, `id_tax_rules_group`, `id_shop`)
                (SELECT ' . (int) $this->id . ', `id_tax_rules_group`, `id_shop`
                FROM `' . _DB_PREFIX_ . 'carrier_tax_rules_group_shop`
                WHERE `id_carrier`=' . (int) $old_id . ')');
    }

    /**
     * Get carrier using the reference id.
     */
    public static function getCarrierByReference($id_reference, $id_lang = null)
    {
        /** @todo class var $table must became static. here I have to use 'carrier' because this method is static */
        $id_carrier = Db::getInstance()->getValue('SELECT `id_carrier` FROM `' . _DB_PREFIX_ . 'carrier`
            WHERE id_reference = ' . (int) $id_reference . ' AND deleted = 0 ORDER BY id_carrier DESC');
        if (!$id_carrier) {
            return false;
        }

        return new Carrier((int) $id_carrier, $id_lang);
    }

    /**
     * Check if Carrier is used (at least one order placed).
     *
     * @return int Order count for this carrier
     */
    public function isUsed()
    {
        $containerFinder = new ContainerFinder(Context::getContext());

        /** @var FeatureFlagStateCheckerInterface $featureFlagManager */
        $featureFlagManager = $containerFinder->getContainer()->get(FeatureFlagStateCheckerInterface::class);

        $row = Db::getInstance()->getRow('
            SELECT COUNT(`id_carrier`) AS total
            FROM `' . _DB_PREFIX_ . 'orders`
            WHERE `id_carrier` = ' . (int) $this->id);

        if ($featureFlagManager->isEnabled(FeatureFlagSettings::FEATURE_FLAG_IMPROVED_SHIPMENT)) {
            $result = Db::getInstance()->getRow('
                SELECT COUNT(`id_carrier`) AS total
                FROM `' . _DB_PREFIX_ . 'shipment`
                WHERE `id_carrier` = ' . (int) $this->id);

            $row['total'] += (int) $result['total'];
        }

        return (int) $row['total'];
    }

    /**
     * Get shipping method of the carrier (free, weight or price).
     *
     * @return int Shipping method enumerator
     */
    public function getShippingMethod()
    {
        if ($this->is_free) {
            return Carrier::SHIPPING_METHOD_FREE;
        }

        $method = (int) $this->shipping_method;

        if ($this->shipping_method == Carrier::SHIPPING_METHOD_DEFAULT) {
            // backward compatibility
            if ((int) Configuration::get('PS_SHIPPING_METHOD')) {
                $method = Carrier::SHIPPING_METHOD_WEIGHT;
            } else {
                $method = Carrier::SHIPPING_METHOD_PRICE;
            }
        }

        return $method;
    }

    /**
     * Get range table of carrier.
     *
     * @return bool|string Range table, false if not found
     */
    public function getRangeTable()
    {
        $shipping_method = $this->getShippingMethod();
        if ($shipping_method == Carrier::SHIPPING_METHOD_WEIGHT) {
            return 'range_weight';
        } elseif ($shipping_method == Carrier::SHIPPING_METHOD_PRICE) {
            return 'range_price';
        }

        return false;
    }

    /**
     * Get Range object, price or weight, depending on the shipping method given.
     *
     * @param int|bool $shipping_method Shipping method enumerator
     *                                  Use false in order to let this method find the correct one
     *
     * @return bool|RangePrice|RangeWeight
     */
    public function getRangeObject($shipping_method = false)
    {
        if (!$shipping_method) {
            $shipping_method = $this->getShippingMethod();
        }

        if ($shipping_method == Carrier::SHIPPING_METHOD_WEIGHT) {
            return new RangeWeight();
        } elseif ($shipping_method == Carrier::SHIPPING_METHOD_PRICE) {
            return new RangePrice();
        }

        return false;
    }

    /**
     * Get range suffix.
     *
     * @param Currency|null $currency Currency
     *
     * @return string Currency sign in suffix to use for the range
     */
    public function getRangeSuffix($currency = null)
    {
        if (!$currency) {
            $currency = Context::getContext()->currency;
        }
        $suffix = Configuration::get('PS_WEIGHT_UNIT');
        if ($this->getShippingMethod() == Carrier::SHIPPING_METHOD_PRICE) {
            $suffix = $currency->sign;
        }

        return $suffix;
    }

    /**
     * Get TaxRulesGroup ID for this Carrier.
     *
     * @param Context|null $context Context
     *
     * @return int TaxrulesGroup ID
     */
    public function getIdTaxRulesGroup(?Context $context = null)
    {
        return Carrier::getIdTaxRulesGroupByIdCarrier((int) $this->id, $context);
    }

    /**
     * Get TaxRulesGroup ID for a given Carrier.
     *
     * @param int $id_carrier Carrier ID
     * @param Context|null $context Context
     *
     * @return int TaxRulesGroup ID
     *             false if not found
     */
    public static function getIdTaxRulesGroupByIdCarrier($id_carrier, ?Context $context = null)
    {
        if (!$context) {
            $context = Context::getContext();
        }
        $key = 'carrier_id_tax_rules_group_' . (int) $id_carrier . '_' . (int) $context->shop->id;
        if (!Cache::isStored($key)) {
            $result = Db::getInstance(_PS_USE_SQL_SLAVE_)
                ->getValue('
                    SELECT `id_tax_rules_group`
                    FROM `' . _DB_PREFIX_ . 'carrier_tax_rules_group_shop`
                    WHERE `id_carrier` = ' . (int) $id_carrier . ' AND id_shop=' . (int) Context::getContext()->shop->id);
            Cache::store($key, $result);

            return (int) $result;
        }

        return (int) Cache::retrieve($key);
    }

    /**
     * Set TaxRulesGroup.
     *
     * @param int $id_tax_rules_group Set the TaxRulesGroup with the given ID
     *                                as this Carrier's TaxRulesGroup
     * @param bool $all_shops True if this should be done for all shops
     *
     * @return bool Whether the TaxRulesGroup has been succesfully set
     *              for this Carrier in this Shop or all given Shops
     */
    public function setTaxRulesGroup($id_tax_rules_group, $all_shops = false)
    {
        if (!Validate::isUnsignedId($id_tax_rules_group)) {
            throw new PrestaShopException('Parameter "id_tax_rules_group" is invalid.');
        }

        if (!$all_shops) {
            $shops = Shop::getContextListShopID();
        } else {
            $shops = Shop::getShops(true, null, true);
        }

        $this->deleteTaxRulesGroup($shops);

        $values = [];
        foreach ($shops as $id_shop) {
            $values[] = [
                'id_carrier' => (int) $this->id,
                'id_tax_rules_group' => (int) $id_tax_rules_group,
                'id_shop' => (int) $id_shop,
            ];
        }
        Cache::clean('carrier_id_tax_rules_group_' . (int) $this->id . '_' . (int) Context::getContext()->shop->id);

        return Db::getInstance()->insert('carrier_tax_rules_group_shop', $values);
    }

    /**
     * Delete TaxRulesGroup from this Carrier.
     *
     * @param array|null $shops Shops
     *
     * @return bool Whether the TaxRulesGroup has been successfully removed from this Carrier
     */
    public function deleteTaxRulesGroup(?array $shops = null)
    {
        if (!$shops) {
            $shops = Shop::getContextListShopID();
        }

        $where = 'id_carrier = ' . (int) $this->id;
        if ($shops) {
            $where .= ' AND id_shop IN(' . implode(', ', array_map('intval', $shops)) . ')';
        }

        return Db::getInstance()->delete('carrier_tax_rules_group_shop', $where);
    }

    /**
     * Returns the Tax rates associated to the Carrier.
     *
     * @param Address $address Address optional
     *
     * @return float Total Tax rate for this Carrier
     */
    public function getTaxesRate(?Address $address = null)
    {
        if (!$address || !$address->id_country) {
            $address = Address::initialize();
        }

        $tax_calculator = $this->getTaxCalculator($address);

        return $tax_calculator->getTotalRate();
    }

    /**
     * Returns the taxes calculator associated to the carrier.
     *
     * @param Address $address Address
     *
     * @return TaxCalculator|AverageTaxOfProductsTaxCalculator Tax calculator object
     */
    public function getTaxCalculator(Address $address, $id_order = null, $use_average_tax_of_products = false)
    {
        if ($use_average_tax_of_products) {
            return ServiceLocator::get('AverageTaxOfProductsTaxCalculator')->setIdOrder($id_order);
        } else {
            $tax_manager = TaxManagerFactory::getManager($address, $this->getIdTaxRulesGroup());

            return $tax_manager->getTaxCalculator();
        }
    }

    /**
     * This tricky method generates a SQL clause to check if ranged data are overloaded by multishop.
     *
     * @param string $range_table Range table
     *
     * @return string SQL quoer to get the delivery range table in this Shop(Group)
     */
    public static function sqlDeliveryRangeShop($range_table, $alias = 'd')
    {
        if (Shop::getContext() == Shop::CONTEXT_ALL) {
            $where = 'AND d2.id_shop IS NULL AND d2.id_shop_group IS NULL';
        } elseif (Shop::getContext() == Shop::CONTEXT_GROUP) {
            $where = 'AND ((d2.id_shop_group IS NULL OR d2.id_shop_group = ' . (int) Shop::getContextShopGroupID() . ') AND d2.id_shop IS NULL)';
        } else {
            $where = 'AND (d2.id_shop = ' . (int) Shop::getContextShopID() . ' OR (d2.id_shop_group = ' . (int) Shop::getContextShopGroupID() . '
                    AND d2.id_shop IS NULL) OR (d2.id_shop_group IS NULL AND d2.id_shop IS NULL))';
        }

        $sql = 'AND ' . $alias . '.id_delivery = (
                    SELECT d2.id_delivery
                    FROM ' . _DB_PREFIX_ . 'delivery d2
                    WHERE d2.id_carrier = `' . bqSQL($alias) . '`.id_carrier
                        AND d2.id_zone = `' . bqSQL($alias) . '`.id_zone
                        AND d2.`id_' . bqSQL($range_table) . '` = `' . bqSQL($alias) . '`.`id_' . bqSQL($range_table) . '`
                        ' . $where . '
                    ORDER BY d2.id_shop DESC, d2.id_shop_group DESC
                    LIMIT 1
                )';

        return $sql;
    }

    /**
     * Moves a carrier.
     *
     * @param bool $way Up (1) or Down (0)
     * @param int|null $position Current position of the Carrier
     *
     * @return bool Whether the update was successful
     */
    public function updatePosition($way, $position)
    {
        if (!$res = Db::getInstance()->executeS(
            'SELECT `id_carrier`, `position`
            FROM `' . _DB_PREFIX_ . 'carrier`
            WHERE `deleted` = 0
            ORDER BY `position` ASC'
        )) {
            return false;
        }

        foreach ($res as $carrier) {
            if ((int) $carrier['id_carrier'] == (int) $this->id) {
                $moved_carrier = $carrier;
            }
        }

        if (!isset($moved_carrier) || !isset($position)) {
            return false;
        }

        // < and > statements rather than BETWEEN operator
        // since BETWEEN is treated differently according to databases
        return Db::getInstance()->execute('
            UPDATE `' . _DB_PREFIX_ . 'carrier`
            SET `position`= `position` ' . ($way ? '- 1' : '+ 1') . '
            WHERE `position`
            ' . ($way
                ? '> ' . (int) $moved_carrier['position'] . ' AND `position` <= ' . (int) $position
                : '< ' . (int) $moved_carrier['position'] . ' AND `position` >= ' . (int) $position . '
            AND `deleted` = 0'))
        && Db::getInstance()->execute('
            UPDATE `' . _DB_PREFIX_ . 'carrier`
            SET `position` = ' . (int) $position . '
            WHERE `id_carrier` = ' . (int) $moved_carrier['id_carrier']);
    }

    /**
     * Reorder Carrier positions
     * Called after deleting a Carrier.
     *
     * @return bool $return
     */
    public static function cleanPositions()
    {
        $return = true;

        $sql = 'SELECT `id_carrier`
                FROM `' . _DB_PREFIX_ . 'carrier`
                WHERE `deleted` = 0
                ORDER BY `position` ASC';
        $result = Db::getInstance()->executeS($sql);

        $i = 0;
        foreach ($result as $value) {
            $return = Db::getInstance()->execute('
                UPDATE `' . _DB_PREFIX_ . 'carrier`
                SET `position` = ' . (int) $i++ . '
                WHERE `id_carrier` = ' . (int) $value['id_carrier']);
        }

        return $return;
    }

    /**
     * Gets the highest carrier position.
     *
     * @return int $position
     */
    public static function getHigherPosition()
    {
        $sql = 'SELECT MAX(`position`)
                FROM `' . _DB_PREFIX_ . 'carrier`
                WHERE `deleted` = 0';
        $position = Db::getInstance()->getValue($sql);

        return (is_numeric($position)) ? $position : -1;
    }

    /**
     * For a given product, gets the carrier available.
     *
     * @param Product $product The id of the product, or an array with at least the package size and weight
     * @param int|null $id_warehouse Warehouse ID - not used anymore
     * @param int|null $id_address_delivery Delivery Address ID
     * @param int|null $id_shop Shop ID
     * @param CartCore|null $cart Cart object
     * @param array|null $error contain an error message if an error occurs
     *
     * @return array Available Carriers
     *
     * @throws PrestaShopDatabaseException
     */
    public static function getAvailableCarrierList(Product $product, $id_warehouse = 0, $id_address_delivery = null, $id_shop = null, $cart = null, &$error = [])
    {
        static $ps_country_default = null;

        if ($ps_country_default === null) {
            $ps_country_default = Configuration::get('PS_COUNTRY_DEFAULT');
        }

        if (null === $id_shop) {
            $id_shop = Context::getContext()->shop->id;
        }
        if (null === $cart) {
            $cart = Context::getContext()->cart;
        }

        if (null === $error || !is_array($error)) {
            $error = [];
        }

        $id_address = (int) ((null !== $id_address_delivery && $id_address_delivery != 0) ? $id_address_delivery : $cart->id_address_delivery);
        if ($id_address) {
            $id_zone = Address::getZoneById($id_address);

            // Check the country of the address is activated
            if (!Address::isCountryActiveById($id_address)) {
                return [];
            }
        } else {
            $country = new Country($ps_country_default);
            $id_zone = $country->id_zone;
        }

        // Does the product is linked with carriers?
        $cache_id = 'Carrier::getAvailableCarrierList_' . (int) $product->id . '-' . (int) $id_shop;
        if (!Cache::isStored($cache_id)) {
            $query = new DbQuery();
            $query->select('id_carrier');
            $query->from('product_carrier', 'pc');
            $query->innerJoin(
                'carrier',
                'c',
                'c.id_reference = pc.id_carrier_reference AND c.deleted = 0 AND c.active = 1'
            );
            $query->where('pc.id_product = ' . (int) $product->id);
            $query->where('pc.id_shop = ' . (int) $id_shop);

            $carriers_for_product = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($query);
            Cache::store($cache_id, $carriers_for_product);
        } else {
            $carriers_for_product = Cache::retrieve($cache_id);
        }

        $carrier_list = [];
        if (!empty($carriers_for_product)) {
            // the product is linked with carriers
            foreach ($carriers_for_product as $carrier) { // check if the linked carriers are available in current zone
                if (Carrier::checkCarrierZone($carrier['id_carrier'], $id_zone)) {
                    $carrier_list[$carrier['id_carrier']] = $carrier['id_carrier'];
                }
            }
            if (empty($carrier_list)) {
                return [];
            }// no linked carrier are available for this zone
        }

        $available_carrier_list = [];
        $cache_id = 'Carrier::getAvailableCarrierList_getCarriersForOrder_' . (int) $id_zone . '-' . (int) $cart->id;
        if (!Cache::isStored($cache_id)) {
            $customer = new Customer($cart->id_customer);
            $carrier_error = [];
            $carriers = Carrier::getCarriersForOrder($id_zone, $customer->getGroups(), $cart, $carrier_error);
            Cache::store($cache_id, [$carriers, $carrier_error]);
        } else {
            list($carriers, $carrier_error) = Cache::retrieve($cache_id);
        }

        $error = array_merge($error, $carrier_error);

        foreach ($carriers as $carrier) {
            $available_carrier_list[$carrier['id_carrier']] = $carrier['id_carrier'];
        }

        if ($carrier_list) {
            $carrier_list = array_intersect($available_carrier_list, $carrier_list);
        } else {
            $carrier_list = $available_carrier_list;
        }

        $cart_quantity = 0;
        $cart_weight = 0;

        foreach ($cart->getProducts(false, false) as $cart_product) {
            if ($cart_product['id_product'] == $product->id) {
                $cart_quantity += $cart_product['cart_quantity'];
            }
            if (isset($cart_product['weight_attribute']) && $cart_product['weight_attribute'] > 0) {
                $cart_weight += ($cart_product['weight_attribute'] * $cart_product['cart_quantity']);
            } else {
                $cart_weight += ($cart_product['weight'] * $cart_product['cart_quantity']);
            }
        }

        if ($product->width > 0 || $product->height > 0 || $product->depth > 0 || $product->weight > 0 || $cart_weight > 0) {
            foreach ($carrier_list as $key => $id_carrier) {
                $carrier = new Carrier($id_carrier);

                // Get the sizes of the carrier and the product and sort them to check if the carrier can take the product.
                $carrier_sizes = [
                    'width' => (int) $carrier->max_width,
                    'height' => (int) $carrier->max_height,
                    'depth' => (int) $carrier->max_depth,
                ];
                $product_sizes = [
                    'width' => (int) $product->width,
                    'height' => (int) $product->height,
                    'depth' => (int) $product->depth,
                ];

                if (($carrier_sizes['width'] > 0 && $carrier_sizes['width'] < $product_sizes['width'])
                    || ($carrier_sizes['height'] > 0 && $carrier_sizes['height'] < $product_sizes['height'])
                    || ($carrier_sizes['depth'] > 0 && $carrier_sizes['depth'] < $product_sizes['depth'])
                ) {
                    $error[$carrier->id] = Carrier::SHIPPING_SIZE_EXCEPTION;
                    unset($carrier_list[$key]);
                }

                if ($carrier->max_weight > 0 && ($carrier->max_weight < $product->weight * $cart_quantity || $carrier->max_weight < $cart_weight)) {
                    $error[$carrier->id] = Carrier::SHIPPING_WEIGHT_EXCEPTION;
                    unset($carrier_list[$key]);
                }
            }
        }

        return $carrier_list;
    }

    /**
     * Assign one (ore more) group to all carriers.
     *
     * @param int|array $id_group_list Group ID or array of Group IDs
     * @param array $exception List of Carrier IDs to ignore
     *
     * @return bool
     */
    public static function assignGroupToAllCarriers($id_group_list, $exception = [])
    {
        if (!is_array($id_group_list)) {
            $id_group_list = [$id_group_list];
        }

        $id_group_list = array_map('intval', $id_group_list);
        $exception = array_map('intval', $exception);

        Db::getInstance()->execute('
            DELETE FROM `' . _DB_PREFIX_ . 'carrier_group`
            WHERE `id_group` IN (' . implode(',', $id_group_list) . ')');

        $carrier_list = Db::getInstance()->executeS('
            SELECT id_carrier FROM `' . _DB_PREFIX_ . 'carrier`
            WHERE deleted = 0
            ' . (is_array($exception) && count($exception) > 0 ? 'AND id_carrier NOT IN (' . implode(',', $exception) . ')' : ''));

        if ($carrier_list) {
            $data = [];
            foreach ($carrier_list as $carrier) {
                foreach ($id_group_list as $id_group) {
                    $data[] = [
                        'id_carrier' => $carrier['id_carrier'],
                        'id_group' => $id_group,
                    ];
                }
            }

            return Db::getInstance()->insert('carrier_group', $data, false, false, Db::INSERT);
        }

        return true;
    }

    /**
     * Set Carrier Groups.
     *
     * @param array $groups Carrier Groups
     * @param bool $delete Delete all previously Carrier Groups which
     *                     were linked to this Carrier
     *
     * @return bool Whether the Carrier Groups have been successfully set
     */
    public function setGroups($groups, $delete = true)
    {
        if ($delete) {
            Db::getInstance()->execute('DELETE FROM ' . _DB_PREFIX_ . 'carrier_group WHERE id_carrier = ' . (int) $this->id);
        }
        if (!is_array($groups) || !count($groups)) {
            return true;
        }
        $sql = 'INSERT INTO ' . _DB_PREFIX_ . 'carrier_group (id_carrier, id_group) VALUES ';
        foreach ($groups as $id_group) {
            $sql .= '(' . (int) $this->id . ', ' . (int) $id_group . '),';
        }

        return Db::getInstance()->execute(rtrim($sql, ','));
    }
}
